﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Xml;
using System.IO;
using System.Reflection;

namespace BobcatUtility
{
    /// <summary>
    /// Name/Value pairs and nested structure
    /// -------------------------------------
    /// This class holds name-value pairs. The names are strings. The values can be:
    /// - strings
    /// - doubles
    /// - floats
    /// - ints
    /// - bools
    /// - PropertyBags
    /// As property bags can be held as values inside another property bags, this means that
    /// you can hold structured data in the property bag.
    /// 
    /// set[Type], get[Type], setDefaultValue[Type]
    /// -------------------------------------------
    /// The main access methods are methods like setString, getString and setDefaultValueString. (They
    /// are also available for other types such as doubles, ints and bools.) The set and get methods
    /// work as you expect. The set methods will overwrite an existing value if there is already one
    /// for the same name.
    /// 
    /// The setDefaultValue[Type] methods allow you to specify a value for a name, but will not overwrite
    /// any existing data. This can be useful, for example, after loading data from a file. You can then
    /// set the default values without changing anything that was loaded.
    /// 
    /// Shorthand format to access data in inner property-bags
    /// ------------------------------------------------------
    /// If you have data in inner property-bags, you can access it using a shorthand format like this...
    ///     string strValue = propertyBag.getString("Bag1.Bag2.Name");
    /// This is equivalent to...
    ///     string strValue = propertyBag.getPropertyBag("Bag1").getPropertyBag("Bag2").getString("Name");
    ///     
    /// Exceptions
    /// ----------
    /// The get() methods throw exceptions if either:
    /// - The named property does not exist in the property-bag, or
    /// - The property does exist, but is not of the requested type.
    /// 
    /// Serialize to XML
    /// ----------------
    /// The property bag can serialize itself to and from XML, and there are helper functions
    /// to load and save it to a file.
    /// 
    /// Observable
    /// ----------
    /// This class is observable via events which are raised when changes are made to the data
    /// we hold. If you have property bags held within property bags, you can just observe the 
    /// outermost property bag and it will raise events if changes are made to any of the nested
    /// objects.
    /// 
    /// Objects and lists of objects
    /// ----------------------------
    /// You can add objects to the property bag. When you add an object, all the public 
    /// properties that have both a get and a set are added. Only properties in the
    /// 'first level' of the object are serialized, and only the types (string, double, int,
    /// bool) supported by the property bag.
    /// 
    /// You can also add ILists of objects.
    /// 
    /// </summary>
    public class PropertyBag
    {
        #region Events

        /// <summary>
        /// Data passed with events raised by this class.
        /// </summary>
        public class Args : EventArgs
        {
        }

        /// <summary>
        /// Event raised when data in the property bag is changed.
        /// </summary>
        public event EventHandler<Args> DataChangedEvent;

        #endregion

        #region Public methods

        /// <summary>
        /// Constructor.
        /// </summary>
        public PropertyBag()
        {
        }

        /// <summary>
        /// A static helper method that creates a property bag and initializes its
        /// contents from XML in the file passed in.
        /// </summary>
        public static PropertyBag createFromFile(string strFilename)
        {
            PropertyBag propertyBag = new PropertyBag();
            propertyBag.readFromFile(strFilename);
            return propertyBag;
        }

        /// <summary>
        /// Adds the string passed in to the property bag.
		/// Note: Call setXML instead of this method to add XML text to the property bag.
        /// </summary>
        public void setString(string strName, string strValue)
        {
            setProperty(strName, new Property(PropertyType.STRING, strValue), true);
        }

        /// <summary>
        /// Adds the double passed in to the property bag.
        /// </summary>
        public void setDouble(string strName, double dValue)
        {
            setProperty(strName, new Property(PropertyType.STRING, dValue.ToString()), true);
        }

        /// <summary>
        /// Adds the float passed in to the property bag.
        /// </summary>
        public void setFloat(string strName, float fValue)
        {
            setProperty(strName, new Property(PropertyType.STRING, fValue.ToString()), true);
        }

        /// <summary>
        /// Adds the int passed in to the property bag.
        /// </summary>
        public void setInt(string strName, int iValue)
        {
            setProperty(strName, new Property(PropertyType.STRING, iValue.ToString()), true);
        }

        /// <summary>
        /// Adds the bool passed in to the property bag.
        /// </summary>
        public void setBool(string strName, bool bValue)
        {
            setProperty(strName, new Property(PropertyType.STRING, bValue.ToString()), true);
        }

        /// <summary>
        /// Adds the property-bag passed in to the property bag.
        /// </summary>
        public void setPropertyBag(string strName, PropertyBag propertyBag)
        {
            propertyBag.m_Parent = this;
            setProperty(strName, new Property(PropertyType.PROPERTY_BAG, propertyBag), true);
        }

        /// <summary>
        /// Adds the object passed in to the property-bag.
        /// </summary>
        public void setObject<T>(string strName, T oValue)
        {
            // We create an inner property bag to hold the object and store it...
            PropertyBag propertyBag = convertObjectToPropertyBag(oValue);
            setPropertyBag(strName, propertyBag);
        }

        /// <summary>
        /// Adds a list of objects to the property bag.
        /// </summary>
        public void setList<T>(string strName, IList<T> list)
        {
            // We create an inner property bag to hold the list, and store it...
            PropertyBag propertyBag = convertListToPropertyBag(list);
            setPropertyBag(strName, propertyBag);
        }

		/// <summary>
		/// Adds the XML string passed in to the property bag. 
		/// </summary>
		public void setXML(string strName, string strValue)
		{
			// We surround the XML with the CDATA element to prevent this data being
			// parsed when we read the property bag from its own XML representation...
			string strXML = "<![CDATA[" + strValue + "]]>";
			setString(strName, strXML);
		}

        /// <summary>
        /// Sets the value for this property if it is not already present.
        /// </summary>
        public void setDefaultValueString(string strName, string strValue)
        {
            setProperty(strName, new Property(PropertyType.STRING, strValue), false);
        }

        /// <summary>
        /// Sets the value for this property if it is not already present.
        /// </summary>
        public void setDefaultValueDouble(string strName, double dValue)
        {
            setProperty(strName, new Property(PropertyType.STRING, dValue.ToString()), false);
        }

        /// <summary>
        /// Sets the value for this property if it is not already present.
        /// </summary>
        public void setDefaultValueFloat(string strName, float fValue)
        {
            setProperty(strName, new Property(PropertyType.STRING, fValue.ToString()), false);
        }

        /// <summary>
        /// Sets the value for this property if it is not already present.
        /// </summary>
        public void setDefaultValueInt(string strName, int iValue)
        {
            setProperty(strName, new Property(PropertyType.STRING, iValue.ToString()), false);
        }

        /// <summary>
        /// Sets the value for this property if it is not already present.
        /// </summary>
        public void setDefaultValueBool(string strName, bool bValue)
        {
            setProperty(strName, new Property(PropertyType.STRING, bValue.ToString()), false);
        }

        /// <summary>
        /// Sets the value for this property if it is not already present.
        /// </summary>
        public void setDefaultValuePropertyBag(string strName, PropertyBag propertyBag)
        {
            propertyBag.m_Parent = this;
            setProperty(strName, new Property(PropertyType.PROPERTY_BAG, propertyBag), false);
        }

        /// <summary>
        /// Sets the value for this property if it is not already present.
        /// </summary>
        public void setDefaultValueObject<T>(string strName, T oValue)
        {
            PropertyBag propertyBag = convertObjectToPropertyBag(oValue);
            setDefaultValuePropertyBag(strName, propertyBag);
        }

        /// <summary>
        /// Sets the value for this property if it is not already present.
        /// </summary>
        public void setDefaultValueList<T>(string strName, IList<T> list)
        {
            // We create an inner property bag to hold the list, and store it...
            PropertyBag propertyBag = convertListToPropertyBag(list);
            setDefaultValuePropertyBag(strName, propertyBag);
        }

		/// <summary>
		/// Sets the value for this property if it is not already present.
		/// </summary>
		public void setDefaultValueXML(string strName, string strValue)
		{
			string strXML = "<![CDATA[" + strValue + "]]>";
			setDefaultValueString(strName, strXML);
		}

		/// <summary>
        /// Returns the string value for the field requested. Throws an exception if the
        /// field is not in the property-bag.
        /// </summary>
        public string getString(string strName)
        {
            return (string)(getProperty(strName, PropertyType.STRING).oValue);
        }

        /// <summary>
        /// Returns the double value for the field requested. Throws an exception if the
        /// field is not in the property-bag.
        /// </summary>
        public double getDouble(string strName)
        {
            return Convert.ToDouble(getString(strName));
        }

        /// <summary>
        /// Returns the float value for the field requested. Throws an exception if the
        /// field is not in the property-bag.
        /// </summary>
        public float getFloat(string strName)
        {
            return Convert.ToSingle(getString(strName));
        }

        /// <summary>
        /// Returns the int value for the field requested. Throws an exception if the
        /// field is not in the property-bag.
        /// </summary>
        public int getInt(string strName)
        {
			return Convert.ToInt32(getString(strName));
		}

        /// <summary>
        /// Returns the bool value for the field requested. Throws an exception if the
        /// field is not in the property-bag.
        /// </summary>
        public bool getBool(string strName)
        {
			return Convert.ToBoolean(getString(strName));
		}

        /// <summary>
        /// Returns the property-bag value for the field requested. Throws an exception if the
        /// field is not in the property-bag.
        /// </summary>
        public PropertyBag getPropertyBag(string strName)
        {
            return (PropertyBag)(getProperty(strName, PropertyType.PROPERTY_BAG).oValue);
        }

        /// <summary>
        /// Reads an object from the property bag and fills in its properties.
        /// </summary>
        public T getObject<T>(string strName) where T : new()
        {
            T result = new T();

            // We read the inner property-bag with the name passed in...
            PropertyBag propertyBag = getPropertyBag(strName);

            // We loop through the properties for this type, and try to find their 
            // values in the property bag...
            Type type = typeof(T);
            PropertyInfo[] propertyInfos = type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly);
            foreach (PropertyInfo propertyInfo in propertyInfos)
            {
                // We check if the property has both a get and a set...
                if (propertyInfo.CanRead == false || propertyInfo.CanWrite == false)
                {
                    continue;
                }

                // We get the name and type of the property...
                string strPropertyName = propertyInfo.Name;
                Type propertyType = propertyInfo.PropertyType;

                // And we read the property from the inner property-bag.
                // This is protected by a try..catch in case the property is not there.
                try
                {
                    object value = null;
                    if (propertyType == typeof(string))
                    {
                        value = propertyBag.getString(strPropertyName);
                    }
                    if (propertyType == typeof(double))
                    {
                        value = propertyBag.getDouble(strPropertyName);
                    }
                    if (propertyType == typeof(int))
                    {
                        value = propertyBag.getInt(strPropertyName);
                    }
                    if (propertyType == typeof(bool))
                    {
                        value = propertyBag.getBool(strPropertyName);
                    }
                    if (value != null)
                    {
                        propertyInfo.SetValue(result, value, null);
                    }
                }
                catch (Exception)
                {
                }
            }

            return result;
        }

        /// <summary>
        /// Returns a list of objects from the property bag.
        /// </summary>
        public IList<T> getList<T>(string strName) where T : new()
        {
            IList<T> results = new List<T>();

            // The list should be held in an inner property-bag...
            PropertyBag propertyBag = getPropertyBag(strName);

            // It contains the number of items in the list...
            int iCount = propertyBag.getInt(LIST_COUNT_FIELD);
            for (int i = 0; i < iCount; ++i)
            {
                string strIndex = "Item" + i.ToString();
                T item = propertyBag.getObject<T>(strIndex);
                results.Add(item);
            }

            return results;
        }

		/// <summary>
		/// Returns an XML string from the property bag.
		/// </summary>
		public string getXML(string strName)
		{
			// We need to remove the CDATA element 'escaping' from the stored XML...
			string strValue = getString(strName);
			int iLength = strValue.Length;
			if (iLength < 12)
			{
				return "";
			}
			string strXML = strValue.Substring(9, iLength - 12);
			return strXML;
		}

        /// <summary>
        /// Renders the property bag into XML, using the string passed in as the outer tag.
        /// </summary>
        public string toXML()
        {
            return "<" + ROOT_ELEMENT + ">" + getPropertiesAsXML(1) + Environment.NewLine + "</" + ROOT_ELEMENT + ">";
        }

        /// <summary>
        /// Parses the XML into properties in this property-bag.
        /// </summary>
        public void fromXML(string strXML)
        {
            // First we clear the properties...
            m_Properties.Clear();

            // We create an XMLDocument to parse...
            XmlDocument xmlDocument = new XmlDocument();
            xmlDocument.InnerXml = strXML;
            XmlNode rootNode = xmlDocument.SelectSingleNode(ROOT_ELEMENT);

            // We find the properties...
            parseXMLProperties(rootNode);
        }

        /// <summary>
        /// Reads XML from the file with the name passed in and populates the property bag from it.
        /// </summary>
        public void readFromFile(string strFilename)
        {
            if (File.Exists(strFilename) == false)
            {
                return;
            }
            string strXML = File.ReadAllText(strFilename);
            fromXML(strXML);
        }

        /// <summary>
        /// Writes an XML representation of the data in this property bag to the file
        /// with the name passed in.
        /// </summary>
        public void writeToFile(string strFilename)
        {
            string strXML = toXML();
            File.WriteAllText(strFilename, strXML);
        }

        #endregion

        #region Private functions

        /// <summary>
        /// Adds the public get/set properties of the object passed in into
        /// a property bag.
        /// </summary>
        private PropertyBag convertObjectToPropertyBag<T>(T oValue)
        {
            // We create a property bag to hold the object...
            PropertyBag propertyBag = new PropertyBag();

            // We find all the properties of the object...
            Type type = typeof(T);
            PropertyInfo[] propertyInfos = type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly);
            foreach (PropertyInfo propertyInfo in propertyInfos)
            {
                // We check if the property has both a get and a set...
                if (propertyInfo.CanRead == false || propertyInfo.CanWrite == false)
                {
                    continue;
                }

                // We get the name and type of the property...
                string strPropertyName = propertyInfo.Name;
                Type propertyType = propertyInfo.PropertyType;

                // And we add the property to the inner property-bag...
                if (propertyType == typeof(string))
                {
                    propertyBag.setString(strPropertyName, (string)propertyInfo.GetValue(oValue, null));
                }
                if (propertyType == typeof(double))
                {
                    propertyBag.setDouble(strPropertyName, (double)propertyInfo.GetValue(oValue, null));
                }
                if (propertyType == typeof(int))
                {
                    propertyBag.setInt(strPropertyName, (int)propertyInfo.GetValue(oValue, null));
                }
                if (propertyType == typeof(bool))
                {
                    propertyBag.setBool(strPropertyName, (bool)propertyInfo.GetValue(oValue, null));
                }
            }
            return propertyBag;
        }


        /// <summary>
        /// Adds the objects passed in into a property bag representing the list.
        /// </summary>
        private PropertyBag convertListToPropertyBag<T>(IList<T> list)
        {
            // To add a list we:
            // 1. Create an inner property-bag
            // 2. Store the number of elements in it
            // 3. Add each object in the list to this property bag, with a numeric index.

            // 1.
            PropertyBag propertyBag = new PropertyBag();

            // 2.
            int iCount = list.Count;
            propertyBag.setInt(LIST_COUNT_FIELD, iCount);

            // 3.
            for (int i = 0; i < iCount; ++i)
            {
                string strIndex = "Item" + i.ToString();
                propertyBag.setObject(strIndex, list[i]);
            }

            return propertyBag;
        }

        /// <summary>
        /// Sets the property passed in. This includes resolving the name if it is in the
        /// dot-delimited format.
        /// </summary>
        private void setProperty(string strName, Property property, bool bOverrideExistingItem)
        {
            // We resolve the name into the property bag and field...
            PropertyBag propertyBag;
            string strField;
            resolveName(strName, true, out propertyBag, out strField);

            // We check if the field already exists...
            bool bExists = propertyBag.m_Properties.ContainsKey(strField);

            // We set property value...
            if (bOverrideExistingItem == true || bExists == false)
            {
                propertyBag.m_Properties[strField] = property;
                raiseDataChangedEvent();
            }
        }

        /// <summary>
        /// We get the property for the name passed in. This includes resolving the name if
        /// it is in the dot-delimited format.
        /// </summary>
        private Property getProperty(string strName, PropertyType eType)
        {
            // We resolve the name into the property bag and field...
            PropertyBag propertyBag;
            string strField;
            resolveName(strName, false, out propertyBag, out strField);

            // We check if the property bag has the field we want...
            propertyBag.hasField_WithException(strField, eType);

            Property result = propertyBag.m_Properties[strField];
            return result;
        }

        /// <summary>
        /// Resolves the name passed in to find which property bag it belongs to. This resolves
        /// names with the short format like "Bag1.Bag2.Name". 
        /// 
        /// If the property bags do not exist they may be created, depending on the value of the
        /// flag passed in.
        /// </summary>
        void resolveName(string strName, bool bCreateNestedPropertyBags, out PropertyBag propertyBag, out string strField)
        {
            string[] tokens = strName.Split('.');
            strField = tokens[tokens.Length - 1];

            // We loop through any property-bag elements, i.e. all except the last one, which 
            // is the property name...
            propertyBag = this;
            for (int i = 0; i < tokens.Length - 1; ++i)
            {
                string strPropertyBagName = tokens[i];
                if(propertyBag.hasField(strPropertyBagName, PropertyType.PROPERTY_BAG) == false)
                {
                    // The nested property-bag does not exist. We create it if requested...
                    if (bCreateNestedPropertyBags == true)
                    {
                        propertyBag.setPropertyBag(strPropertyBagName, new PropertyBag());
                    }
                    else
                    {
                        string strMessage = "Could not resolve name: " + strName;
                        throw new Exception(strMessage);
                    }
                }
                propertyBag = propertyBag.getPropertyBag(strPropertyBagName);
            }
        }

        /// <summary>
        /// We find the property elements from the XML and parse them to find the 
        /// properties themselves, which we add to the proprty-bag.
        /// </summary>
        private void parseXMLProperties(XmlNode node)
        {
            // We find the collection of properties in this node...
			XmlNodeList propertyNodes = node.ChildNodes;
            foreach (XmlNode propertyNode in propertyNodes)
            {
                // We find the name and type of this property...
				string strName = propertyNode.Name;

				// Does this node have child nodes? If it does not, then it's 
				// a standard property. If it does, then it is a property-bag...
				if (propertyNode.FirstChild.HasChildNodes == false)
				{
					// We've got a standard property...
					string strValue = propertyNode.InnerXml.Trim();
					setString(strName, strValue);
				}
				else
				{
					// We've got a property bag...
					PropertyBag innerPropertyBag = new PropertyBag();
					innerPropertyBag.parseXMLProperties(propertyNode);
					setPropertyBag(strName, innerPropertyBag);
				}
            }
        }

        /// <summary>
        /// Renders this property bag into XML using the string passed in as the
        /// name of the outer element. It is indented by the level passed in.
        /// </summary>
        private string getPropertiesAsXML(int iIndentationLevel)
        {
            string strIndentation = getIndentation(iIndentationLevel);

            // We loop through the properties in this bag, adding them to the XML...
            string strXML = "";
            foreach (KeyValuePair<string, Property> pair in m_Properties)
            {
                // We add each property into an element like:
                // <property name="[abc]" type="double">
                //   123.456
                // </property>
                string strName = pair.Key;
                Property property = pair.Value;

                string strValue = "";
                switch (property.eType)
                {
                    case PropertyType.STRING:
                        strValue = property.oValue.ToString();
                        break;

                    case PropertyType.PROPERTY_BAG:
                        PropertyBag innerPropertyBag = (PropertyBag)(property.oValue);
                        strValue = innerPropertyBag.getPropertiesAsXML(iIndentationLevel + 1) + strIndentation;
                        break;
                }

                string strPropertyXML = strIndentation;
                strPropertyXML += "<" + strName + ">" + strValue + "</" + strName + ">";
                strXML += strPropertyXML;
            }

            return strXML;
        }

        /// <summary>
        /// Returns a string with spaces for the indentation level used for one
        /// level of XML rendering.
        /// </summary>
        private string getIndentation(int iIndentationLevel)
        {
            string strResult = Environment.NewLine;
            for (int i = 0; i < iIndentationLevel; ++i)
            {
                strResult += "  ";
            }
            return strResult;
        }

        /// <summary>
        /// Raises a data-changed event. If this property bag is nested within an outer 
        /// one we raise the event on the outermost one.
        /// </summary>
        private void raiseDataChangedEvent()
        {
            // We find the outermost property-bag and raise the event on it...
            PropertyBag m_Outermost = this;
            while (m_Outermost.m_Parent != null)
            {
                m_Outermost = m_Outermost.m_Parent;
            }
            Utility.raiseEvent(m_Outermost.DataChangedEvent, m_Outermost, new Args());

            // If we are not the outermost, we also raise the event from this object in case anyone
            // is observing us...
            if (m_Outermost != this)
            {
                Utility.raiseEvent(DataChangedEvent, this, new Args());
            }
        }

        /// <summary>
        /// Returns true if we hold the field and it is of the right type, otherwise false.
        /// </summary>
        private bool hasField(string strName, PropertyType eType)
        {
            // Do we have the field?
            if (m_Properties.ContainsKey(strName) == false)
            {
                return false;
            }

            // We do have the field, but is it the right type?
            Property property = m_Properties[strName];
            if (property.eType != eType)
            {
                return false;
            }

            return true;
        }

        /// <summary>
        /// Checks if we hold the field passed in and that it is the right type, and throws 
        /// an exception if we do not.
        /// </summary>
        private void hasField_WithException(string strName, PropertyType eType)
        {
            if (hasField(strName, eType) == false)
            {
                string strMessage = "Property bag does not contain field '" + strName + "' or it is not the requested type.";
                throw new Exception(strMessage);
            }
        }

        #endregion

        #region Private data

        // Constants...
        private const string ROOT_ELEMENT = "properties";
        private string LIST_COUNT_FIELD = "ListCount";

        // Enum for the different types of properties we can hold.
        private enum PropertyType
        {
            STRING,
            PROPERTY_BAG
        }

        // Holds information about one property held in the bag...
        private class Property
        {
            public Property(PropertyType type, object value)
            {
                eType = type;
                oValue = value;
            }
            public PropertyType eType;
            public object oValue;
        }

        // The property bag itself...
        private IDictionary<string, Property> m_Properties = new Dictionary<string, Property>();

        // A reference to our parent if this property bag is held within another one...
        private PropertyBag m_Parent = null;

        #endregion
    }
}
